외부 자동화 도구가 AhaWiki 페이지를 직접 수정할 수 있도록 사용자 개인 API Key 인증을 추가했다.
세션 쿠키와 reCAPTCHA 없이 Authorization: Bearer <key> 헤더로 페이지를 읽고 저장할 수 있는 API를 제공한다.
페이지 히스토리에는 편집자가 기존 사용자로 기록되며, API Key를 통한 편집은 Page.viaApi = TRUE로 표시한다.
Page.viaApi = TRUE로 기록한다.Page.userApiKey nullable FK로 함께 기록한다.viaApi(불변 사실)와 userApiKey(삭제/이름변경 가능한 엔티티 참조)는 서로 다른 질문에 답하므로 둘 다 유지한다.userApiKey IS NOT NULL이면 viaApi = TRUE다. 역은 성립하지 않는다(viaApi = TRUE && userApiKey = NULL은 "API로 저장됐지만 그 키는 이후 삭제됨").viaApi 사실이 보존되도록 ON DELETE SET NULL을 쓴다. 현재 key는 soft-delete(dateRevoked)만 하므로 평상시 FK는 끊기지 않는다.UserApiKey.name은 사람이 읽을 수 있는 키 이름이다. 화면/응답에는 join으로 현재 이름을 노출하고, key가 없으면 이름 없이 viaApi만 표시한다.WikiPermission 규칙과 key 소유자 사용자의 권한을 그대로 따른다.UserApiKey 테이블은 사용자별 API Key 메타데이터와 hash를 저장한다.
seq BIGINT AUTO_INCREMENT PRIMARY KEYuser INT NOT NULL — User.seq FKkeyHash VARCHAR(64) NOT NULL UNIQUE — SHA-256 hexkeyPrefix VARCHAR(32) NOT NULL — 목록에서 key를 식별하기 위한 prefixname VARCHAR(255) NOT NULL — 사람이 읽을 수 있는 키 이름 (이전 label에서 rename)dateInserted DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMPdateLastUsed DATETIME NULLdateRevoked DATETIME NULLINDEX (user, dateRevoked) — 사용자별 key 목록 조회keyHash UNIQUE가 인증 조회 인덱스 역할을 하므로 별도 INDEX (keyHash)는 만들지 않는다.
Page 테이블에 viaApi BOOLEAN NOT NULL DEFAULT FALSE 컬럼을 추가했다.
FALSE다.TRUE로 기록한다.Page, PageWithoutContent, row parser, history 조회, insert SQL에 모두 viaApi를 포함한다.PageLogic.insert는 viaApi: Boolean = false 기본값을 받고, API Key 저장 시 true를 전달한다.Page 테이블에 userApiKey BIGINT NULL 컬럼과 Page_UserApiKey_seq_fk FOREIGN KEY (userApiKey) REFERENCES UserApiKey (seq) ON DELETE SET NULL을 추가했다.
NULL이고, API Key 저장만 해당 key의 seq를 기록한다.Page에는 userApiKey: Option[Long] seq만 둔다. PageWithoutContent는 history 표시용으로 join한 userApiKeyName: Option[String]도 갖는다.PageLogic.insert는 userApiKey: Option[Long] = None 파라미터를 받는다.ApiV1.savePage / renamePage는 withApiUserAndKey로 인증 key를 받아 userApiKey = Some(apiKey.seq)를 전달한다.UserApiKey 에서 조회한다 — 변경 목록은 LEFT JOIN, 페이지 목록·메타·본문은 UserApiKey.selectNamesBySeqs. 비정규화 스냅샷은 두지 않는다.SessionLogic.getApiKeyUser(request)가 Authorization: Bearer <key> 헤더를 읽고 raw key를 SHA-256으로 hash한 뒤 UserApiKey에서 활성 key를 조회한다.
인증 성공 시:
dateLastUsed를 갱신한다.User.SessionUser를 만든다.getApiKeyWithUser는 인증 key(UserApiKey)와 SessionUser를 함께 반환하고, getApiKeyUser는 그중 사용자만 돌려준다.
저장/이름변경처럼 userApiKey를 기록해야 하는 endpoint는 withApiUserAndKey로 둘을 함께 받는다.
AhaWiki API에서는 RequestWrapper.forUser(user)로 인증 사용자를 ContextWikiPage와 WikiPermission에 전달한다.
이 처리가 없으면 인증은 성공해도 권한 계산이 익명 사용자 기준으로 동작할 수 있다.
Authorization: Bearer <key> 를 읽는 코드는 SessionLogic.getApiKeyWithUser 하나뿐이고, 그것을 부르는 것은 ApiV1 뿐이다.
그 밖의 endpoint 는 어떤 key 를 붙여도 key 를 보지 않는다.
그런데 헤더를 그냥 무시하지도 않는다. CSRF filter 를 건너뛸 경로를 정하는 것은 app/Filters.scala 이고, 거기서 빠지는 경로는 CSRF 검사를 받는다.
Play 의 기본값 play.filters.csrf.header.protectHeaders 는 Cookie 나 Authorization 이 붙은 요청만 검사 대상으로 삼는데, 이 저장소는 conf/base.conf 에서 그 값을 바꾸지 않는다.
그래서 검사 대상이 아니던 요청에 Authorization 헤더를 붙이는 순간 CSRF token 이 필요해지고, token 이 없으면 403 이다.
이 403 은 key 가 틀렸다는 뜻이 아니다. key 는 읽히지도 않는다.
유효한 key 든 아무 문자열이든 결과가 같고, 헤더를 빼면 같은 요청이 그대로 성공한다.
응답 본문이 Play 의 기본 오류 페이지라 제목에 Unauthorized 가 찍히는 것도 헷갈리는 이유다 — 인증이 거절된 것이 아니다.
2026-09-19 에 https://aha00a.com 에서 확인한 결과:
요청 | Authorization | 결과 |
|---|---|---|
| 없음 |
|
| 아무 문자열 |
|
| 유효한 key |
|
| 없음 |
|
| 유효한 key |
|
상태 코드로 구분하면, AhaWiki API 는 key 가 없거나 잘못됐을 때 401 을 주고 권한이 없을 때 403 을 준다(위 «인증과 권한»).
AhaWiki API 밖에서 받는 403 은 둘 중 어느 쪽도 아니라서, key 를 새로 발급하거나 바꿔도 사라지지 않는다.
Authorization 헤더 없이 부른다. /api/renderAhaMark/... 같은 예전 endpoint 는 원래 그렇게 쓰는 것이다.Cookie 를 늘 보내므로 헤더를 빼는 것만으로는 부족하다. GET /api/csrf 로 token 을 받아 Csrf-Token 헤더에 담아야 한다 — AhaWiki.Kanban.js 가 그렇게 한다.403 응답에는 Set-Cookie: PLAY_SESSION=; Max-Age=0 이 붙는다. 같은 요청이 200 일 때는 붙지 않는다. 브라우저에서 부른 경우 세션 쿠키가 지워지므로, 증상이 403 하나로 끝나지 않는다.protectHeaders 에서 Authorization 을 빼지 않았다. 그러면 403 은 사라지지만 요청이 인증되지도 않는다 — 예전 endpoint 는 key 를 읽지 않으니 여전히 익명 요청이고, 대신 Play 의 기본 보호 범위만 좁아진다.외부 사용 설명서는 Api에 둔다.
GET /api/v1/page/*nameEncodedPOST /api/v1/page/*nameEncodedGET /api/v1/pagesPOST /api/v1/pages/metadataGET /api/v1/changesPOST /api/v1/renameDELETE /api/v1/page/*nameEncoded저장 API는 JSON body의 revision, text, comment, minorEdit를 사용한다.
revision이 최신과 다르면 409 Conflict를 반환한다.
현재 API 저장은 PageLogic.insert(..., viaApi = true), cache invalidate, page calculation enqueue까지만 수행한다.
웹 편집의 websocket broadcast와 Telegram 알림은 보내지 않는다.
Telegram 알림은 minorEdit와 viaApi 저장을 제외한다.
AhaWikiDoc sync를 위해 AhaWiki API를 보강했다.
name, revision, dateTime, isMinorEdit, viaApi, userApiKeyName, size, contentHash를 반환한다.GET /api/v1/pages 는 2026-09-10 까지 userApiKeyName 을 항상 null 로 줬다 — 목록 SQL 이 userApiKey 컬럼을 읽지 않아 Page 의 기본값 None 이 그대로 나갔고, 메타·본문 endpoint 만 이름을 답했다. 지금은 읽고, ApiV1Spec 이 목록에서도 이름을 단언한다.revision, dateTime, hash를 한 번에 조회한다.afterRevision은 페이지별 revision이므로 name으로 단일 페이지를 지정한 경우에만 허용한다.viaApi = true redirect page를 만든다.confirm: true를 요구하며, 웹 삭제와 같은 정책으로 첨부파일도 삭제 처리한다.lastSyncedAt, page별 revision, dateTime, contentHash를 기록하는 방식을 권장한다.POST /api/v1/rename으로 기존 page history를 보존한다.Account Settings에 API Key 관리 섹션을 추가했다.
목록 조회에서는 plain text key를 반환하지 않고 keyPrefix만 보여준다.
이 화면은 Admin SPA가 아니라 위키 문서와 같은 껍데기를 쓴다. Account/settings.scala.html이 _base 레이아웃 안에서 wikiContent · limitWidth를 두르고, 표는 wikiTableSimple이다. Admin 화면(React · Mantine)과 다른 쪽을 고른 것이므로, 이 화면을 고칠 때 Admin 쪽 컴포넌트를 가져오면 톤이 어긋난다.
세션 기반 내부 API:
GET /api/account/ApiKeysPOST /api/account/ApiKeysDELETE /api/account/ApiKeys/:seq이 API는 로그인 세션과 CSRF token이 필요하다.
POST 응답에만 plain text key를 포함하고, 이후 조회에서는 keyPrefix만 반환한다.
Admin SPA에 /Admin/ApiKeys 화면을 추가했다.
세션 기반 내부 Admin API:
GET /api/Admin/ApiKeysDELETE /api/Admin/ApiKeys/:seqAdmin 권한과 CSRF token이 필요하다.
viaApi는 사용자가 자동화 편집을 구분할 수 있도록 여러 화면과 API에 노출한다.
Api.change 응답에 viaApi 포함ApiAdminReport.adminRecentChanges 응답에 viaApi 포함Show ViaApi 필터 추가RecentChanges 매크로에 Include via API edits 토글과 minor edit, Via API 이모지 컬럼 추가/api/change에 includeViaApi 파라미터 추가viaApi 편집은 어느 key였는지도 함께 보여준다. key 이름은 그때 UserApiKey 에서 조회한다(방법은 위 «Data Model» 의 Page.userApiKey). 폐기는 dateRevoked 를 채울 뿐 행을 지우지 않고 조회도 폐기 여부를 보지 않으므로, 폐기한 key 의 이름도 그대로 나온다. 이름이 빠지는 것은 행을 DB 에서 직접 지웠을 때뿐이다 — Page.userApiKey 의 FK 가 ON DELETE SET NULL 이라 viaApi 만 남는다.
Api.change, ApiAdminReport.adminRecentChanges, ApiV1.changes, 페이지 목록/메타(ApiV1.listPages / pageMetadata / getPage) 응답에 userApiKeyName 포함RecentChanges 매크로는 각 revision comment에 [viaApi:<name>] prefix로 표시makeFlagCell의 detail 인자)SecureRandom으로 32 bytes를 생성하고 Base64 URL-safe 문자열로 표시한다. 형식은 ahawiki_<token>이다.app/Filters.scala가 정하고, 그 밖의 경로에 Authorization을 붙이면 어떻게 되는지는 위 «Bearer 인증은 v1 경로에서만 동작한다»에 있다.ApiV1SpecviaApi = TRUE, userApiKey seq 기록, 읽기 응답의 userApiKeyName 노출409 Conflictsince, includeMinorEdit, includeViaApi, invalid since 검증afterRevision을 단일 페이지에만 허용하는 정책 검증ApiV1FilterSpecFilters 체인에서 /api/v1/ POST가 CSRF token 없이 Bearer 인증만으로 저장되는지 검증UnitTestSuiteSpecTestSchema 가 schema/schema.sql 덤프에서 만든다(2026-08-10 부터). viaApi, userApiKey 컬럼은 덤프를 갱신하면 따라온다 — 손으로 쓴 Page schema 는 이제 없다관련 테스트:
sbt.bat "testOnly com.aha00a.controllers.ApiV1Spec"sbt.bat "testOnly com.aha00a.controllers.ApiV1FilterSpec"마지막 확인 시 ApiV1Spec 18개 테스트가 통과했다.
Similar pages by cosine similarity. Words after page name are term frequency.